spb/worthdoing Public
Autonomous investigation agent that discovers, challenges, and ranks things genuinely worth doing — Claude + Firecrawl, Next.js 16, PostgreSQL
TypeScript 91.5%
SQL 5.8%
CSS 2.2%
1/**2 * WorthDoing.ai3 * Author: Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File: src/app/investigate/[id]/InvestigationLive.tsx6 * Description: Live investigation client view — SSE timeline, animating hypotheses, opportunities, streamed reports; mobile tabs.7 */8"use client";910import { useCallback, useEffect, useRef, useState } from "react";11import Link from "next/link";12import { cn } from "@/lib/utils";13import {14 useInvestigationEvents,15 useSnapshot,16 type UiAgentEvent,17} from "@/lib/ui/api";18import { Timeline } from "@/components/wd/Timeline";19import { HypothesisPanel } from "@/components/wd/HypothesisPanel";20import { StatsBar } from "@/components/wd/StatsBar";21import { PhaseBadge, InvestigationStatusBadge } from "@/components/wd/badges";22import { WorthScore } from "@/components/wd/OpportunityCard";23import { Markdown } from "@/components/wd/Markdown";2425const STRUCTURAL_EVENTS = new Set([26 "investigation.started",27 "phase.changed",28 "budget.updated",29 "hypothesis.created",30 "hypothesis.updated",31 "hypothesis.rejected",32 "evidence.saved",33 "opportunity.created",34 "report.completed",35 "investigation.completed",36 "investigation.failed",37]);3839type MobileTab = "timeline" | "hypotheses" | "opportunities";4041export function InvestigationLive({ id }: { id: string }) {42 const { snapshot, refresh, error } = useSnapshot(id);43 const [events, setEvents] = useState<UiAgentEvent[]>([]);44 const [reportDraft, setReportDraft] = useState<{ opportunityId: string; text: string } | null>(null);45 const [tab, setTab] = useState<MobileTab>("timeline");46 const feedRef = useRef<HTMLDivElement>(null);47 const refreshTimer = useRef<ReturnType<typeof setTimeout> | null>(null);4849 const onEvent = useCallback(50 (e: UiAgentEvent) => {51 if (e.type === "report.delta") {52 const oid = String(e.payload.opportunityId);53 setReportDraft((prev) =>54 prev && prev.opportunityId === oid55 ? { opportunityId: oid, text: prev.text + String(e.payload.delta) }56 : { opportunityId: oid, text: String(e.payload.delta) },57 );58 return;59 }60 if (e.type === "report.started") setReportDraft({ opportunityId: String(e.payload.opportunityId), text: "" });61 if (e.type === "report.completed") setReportDraft(null);6263 setEvents((prev) => {64 if (prev.some((p) => p.seq === e.seq)) return prev;65 const next = [...prev, e].sort((a, b) => a.seq - b.seq);66 return next.length > 400 ? next.slice(next.length - 400) : next;67 });6869 if (STRUCTURAL_EVENTS.has(e.type)) {70 // Debounce snapshot refreshes so bursts don't stampede the API.71 if (refreshTimer.current) clearTimeout(refreshTimer.current);72 refreshTimer.current = setTimeout(refresh, 400);73 }74 },75 [refresh],76 );7778 const { connected } = useInvestigationEvents(id, onEvent);7980 // Keep the feed pinned to the latest event.81 useEffect(() => {82 const el = feedRef.current;83 if (el) el.scrollTop = el.scrollHeight;84 }, [events.length, reportDraft?.text.length]);8586 if (error) {87 return (88 <div className="mx-auto max-w-2xl px-4 py-24 text-center">89 <p className="font-heading text-2xl text-ink">Investigation not found</p>90 <p className="mt-2 text-sm text-ink-soft">{error}</p>91 <Link href="/" className="mt-6 inline-block rounded-md bg-ink px-4 py-2 text-sm text-paper">92 Start a new one93 </Link>94 </div>95 );96 }97 if (!snapshot) {98 return (99 <div className="flex items-center justify-center py-32">100 <span className="wd-live-dot h-2.5 w-2.5 rounded-full bg-verdict" />101 </div>102 );103 }104105 const inv = snapshot.investigation;106 const isLive = inv.status === "running" || inv.status === "pending";107108 const rightColumn = (109 <>110 <section className="rounded-xl border border-line bg-card p-4">111 <h2 className="mb-3 flex items-center justify-between font-mono text-[11px] uppercase tracking-widest text-ink-soft">112 Hypotheses113 <span className="text-ink">{snapshot.hypotheses.length}</span>114 </h2>115 <HypothesisPanel hypotheses={snapshot.hypotheses} />116 </section>117118 <section className="rounded-xl border border-line bg-card p-4">119 <h2 className="mb-3 flex items-center justify-between font-mono text-[11px] uppercase tracking-widest text-ink-soft">120 Opportunities121 <span className="text-ink">{snapshot.opportunities.length}</span>122 </h2>123 {snapshot.opportunities.length === 0 ? (124 <p className="py-4 text-center font-mono text-xs text-ink-soft">125 none yet — opportunities appear when hypotheses survive the skeptic126 </p>127 ) : (128 <ul className="space-y-3">129 {snapshot.opportunities.map((o) => (130 <li key={o.id} className="wd-enter">131 <Link132 href={o.hasReport ? `/opportunity/${o.id}` : "#"}133 className={cn(134 "flex items-start justify-between gap-3 rounded-lg border border-line p-3 transition-colors",135 o.hasReport ? "hover:border-verdict/40 hover:bg-accent/50" : "cursor-default opacity-80",136 )}137 >138 <div className="min-w-0">139 <p className="text-[13px] font-medium leading-snug text-ink">{o.title}</p>140 <p className="mt-1 line-clamp-2 text-xs text-ink-soft">{o.summary}</p>141 {!o.hasReport && (142 <p className="mt-1 font-mono text-[10px] uppercase tracking-wider text-signal">143 report in progress…144 </p>145 )}146 </div>147 <WorthScore score={o.worthScore} confidence={o.evidenceConfidence} />148 </Link>149 </li>150 ))}151 </ul>152 )}153 </section>154155 {snapshot.sources.length > 0 && (156 <section className="rounded-xl border border-line bg-card p-4">157 <h2 className="mb-3 flex items-center justify-between font-mono text-[11px] uppercase tracking-widest text-ink-soft">158 Sources with evidence159 <span className="text-ink">{snapshot.sources.length}</span>160 </h2>161 <ul className="space-y-1.5">162 {snapshot.sources.map((s) => (163 <li key={s.id} className="truncate">164 <a165 href={s.canonicalUrl}166 target="_blank"167 rel="noopener noreferrer"168 className="font-mono text-[11px] text-tele hover:underline"169 >170 {s.title ?? s.canonicalUrl}171 </a>172 </li>173 ))}174 </ul>175 </section>176 )}177 </>178 );179180 const timelinePane = (181 <section className="flex min-h-0 flex-col rounded-xl border border-line bg-card">182 <div className="flex items-center justify-between border-b border-line px-4 py-2.5">183 <h2 className="font-mono text-[11px] uppercase tracking-widest text-ink-soft">Investigation log</h2>184 <span185 className={cn(186 "flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-wider",187 connected ? "text-verdict" : "text-signal",188 )}189 >190 <span className={cn("h-1.5 w-1.5 rounded-full bg-current", connected && isLive && "wd-live-dot")} />191 {connected ? (isLive ? "live" : "replay") : "reconnecting"}192 </span>193 </div>194 <div ref={feedRef} className="min-h-[300px] flex-1 overflow-y-auto p-4 lg:max-h-[calc(100vh-260px)]">195 <Timeline events={events} />196 {reportDraft && (197 <div className="wd-enter mt-4 rounded-lg border border-verdict/30 bg-accent/40 p-4">198 <p className="mb-2 font-mono text-[10px] uppercase tracking-widest text-verdict">199 writing report — streaming200 </p>201 <p className="whitespace-pre-wrap text-[13px] leading-relaxed text-ink/90">202 {reportDraft.text}203 <span className="wd-live-dot ml-0.5 inline-block h-3.5 w-[2px] translate-y-0.5 bg-verdict" />204 </p>205 </div>206 )}207 </div>208 </section>209 );210211 return (212 <div className="mx-auto w-full max-w-6xl px-4 py-6 sm:px-6">213 <div className="mb-4 flex flex-wrap items-center gap-2">214 <InvestigationStatusBadge status={inv.status} live />215 <PhaseBadge phase={inv.phase} live={isLive} />216 <span className="ml-auto font-mono text-[10px] uppercase tracking-wider text-ink-soft">{inv.model}</span>217 </div>218 <h1 className="mb-4 max-w-4xl font-heading text-2xl font-semibold leading-tight text-ink sm:text-3xl">219 {inv.objective}220 </h1>221222 {inv.status === "completed" && inv.conclusion && (223 <div className="wd-enter mb-4 rounded-xl border border-verdict/30 bg-accent/50 p-4">224 <p className="font-mono text-[10px] uppercase tracking-widest text-verdict">225 conclusion — {inv.outcome?.replaceAll("_", " ")}226 </p>227 <div className="mt-2">228 <Markdown>{inv.conclusion}</Markdown>229 </div>230 </div>231 )}232 {inv.status === "failed" && (233 <div className="mb-4 rounded-xl border border-rust/30 bg-rust/5 p-4">234 <p className="font-mono text-[10px] uppercase tracking-widest text-rust">investigation failed</p>235 <p className="mt-2 text-sm text-ink">{inv.error ?? "Unknown error."}</p>236 </div>237 )}238239 <div className="sticky top-14 z-30 -mx-1 bg-paper/95 px-1 py-2 backdrop-blur-sm">240 <StatsBar241 budget={inv.budget}242 used={inv.budgetUsed}243 evidenceCount={snapshot.evidenceCount}244 hypothesisCount={snapshot.hypotheses.length}245 />246 </div>247248 {/* Mobile tabs */}249 <div className="mt-3 flex gap-1 rounded-lg border border-line bg-card p-1 lg:hidden">250 {(251 [252 ["timeline", "Timeline"],253 ["hypotheses", "Hypotheses"],254 ["opportunities", "Results"],255 ] as [MobileTab, string][]256 ).map(([key, label]) => (257 <button258 key={key}259 onClick={() => setTab(key)}260 className={cn(261 "min-h-[44px] flex-1 rounded-md text-sm font-medium transition-colors",262 tab === key ? "bg-ink text-paper" : "text-ink-soft hover:text-ink",263 )}264 >265 {label}266 </button>267 ))}268 </div>269270 {/* Mobile stacked view */}271 <div className="mt-3 space-y-4 lg:hidden">272 {tab === "timeline" && timelinePane}273 {tab !== "timeline" && (274 <div className="space-y-4">275 {tab === "hypotheses" ? (276 <section className="rounded-xl border border-line bg-card p-4">277 <HypothesisPanel hypotheses={snapshot.hypotheses} />278 </section>279 ) : (280 rightColumn281 )}282 </div>283 )}284 </div>285286 {/* Desktop split view */}287 <div className="mt-4 hidden gap-4 lg:grid lg:grid-cols-[1.4fr_1fr]">288 {timelinePane}289 <div className="space-y-4">{rightColumn}</div>290 </div>291 </div>292 );293}294